Skip to content

feat(firewall): download from a random origin with cross-origin fallback - #18

Merged
Julian Gruber (juliangruber) merged 1 commit into
mainfrom
julian/sfw-mirror-origin
Oct 1, 2026
Merged

Julian Gruber (juliangruber) merged 1 commit into
mainfrom
julian/sfw-mirror-origin

Conversation

@juliangruber

@juliangruber Julian Gruber (juliangruber) commented Sep 23, 2026 •

Copy link
Copy Markdown
Member

The free-edition binary is now downloadable from two origins: GitHub release assets, and the Socket-owned mirror at install.socket.dev/firewall/dl/<version>/<binary> (served by SocketDev/depscan#26562). Each job picks one origin at random, so roughly half the fleet's downloads keep the mirror's edge cache warm — which is what lets it keep serving pinned binaries during a GitHub release-asset incident.

downloadToolWithRetry now takes the origin list and alternates across the existing 30s/60s retry schedule. An error a retry cannot change (a 404) still gets one immediate try of the other origin, because a missing asset on one host says nothing about the other — which also makes this safe to merge before the mirror endpoint is deployed: until then the mirror 404s and every download falls through to GitHub at the cost of one request.

The checksum table in this action's source validates every download regardless of origin, so the second host cannot alter what gets installed. Enterprise stays GitHub-only: firewall-release is private and not mirrored.

dist/ is rebuilt.


Note

Medium Risk
Changes how CI jobs fetch and verify the firewall binary (supply-chain path), though checksum pinning and enterprise-only GitHub downloads limit exposure.

Overview
Adds a second download origin for the free Socket Firewall binary at install.socket.dev, with per-job random ordering between GitHub and the mirror so installs stay resilient when one host fails and mirror traffic stays warm.

downloadToolWithRetry now accepts multiple equivalent URLs: it rotates origins across the existing 30s/60s backoff for retryable errors, and on 404-style failures tries the other origin immediately without waiting. Enterprise installs remain GitHub-only; pinned checksum validation is unchanged for every download.

A new CI workflow blocks either GitHub or the mirror via the hosts file on Windows and Ubuntu and asserts the action still installs and runs sfw --version. Unit tests cover URL construction, shuffle order, and multi-origin retry behavior; dist/ is rebuilt.

Reviewed by Cursor Bugbot for commit 6601666. Configure here.

@juliangruber
Julian Gruber (juliangruber) changed the base branch from ruxandrafediuc/bump-sfw-1.15.2-and-download-retry to main September 29, 2026 12:00
Comment thread src/tools/firewall.js Outdated
Comment thread src/tools/firewall.js Outdated
Julian Gruber (juliangruber) added a commit that referenced this pull request Sep 29, 2026
Review on #18: firewallDownloadUrls now returns the equivalent origins in
a fixed order, GitHub first. The per-job coin flip lives in
orderDownloadOrigins, which downloadFirewall applies to that list. The
mirror constant is named for the edition it serves.
@juliangruber

This comment was marked as outdated.

Comment thread src/tools/firewall.js Outdated
Comment thread .github/workflows/test-sfw-mirror.yml Outdated
Comment thread src/tools/firewall.js Outdated
attempt <= DOWNLOAD_RETRY_DELAYS_SECONDS.length;
attempt += 1
) {
const url = urls[attempt % urls.length]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: the two origins share one 3-attempt counter, so a dead origin uses up the other's retries.

Example: GitHub returns 504 while the mirror returns 404/403.

  • [github, mirror] order: GitHub, 30s wait, mirror 4xx, then throw. GitHub never gets its second and third tries.
  • [mirror, github] order: mirror 404, GitHub 504, 60s wait, then the mirror again for a guaranteed 404.

In both cases GitHub gets one try instead of the three it gets on main, and the error thrown is the 4xx, which hides the real 504. That's the scenario this PR is meant to fix.

Fix: track each origin separately. Drop an origin after a non-retryable error, and keep retrying the ones still alive so each gets at least main's budget. Throw the most useful error. Tests to add: [M 404, G 504, G ok] succeeds, and [G 504, M 403] retries GitHub.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 6601666. Each origin now gets the whole 30s/60s schedule on its own: a round tries every origin still in play, and only a round with no success sits out the next delay. An origin that fails with a non-retryable status (404, 403) drops out of later rounds. The error rethrown is the last transient one when there was one, otherwise the last error seen.

Both scenarios are now unit tests:

  • [M 404, G 504, G ok] succeeds with calls [M], [G], [G] and one 30s wait.
  • [G 504, M 403] retries GitHub ([G], [M], [G], [G], waits 30s and 60s) and throws the 504, not the 403.

One consequence worth stating: when both origins are down with 5xx the worst case is six downloadTool calls plus 90s instead of three plus 90s, since each origin keeps its own budget.

@Andre153 Andre Coetzee (Andre153) left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One blocker inline: the shared retry counter across origins makes a GitHub 504 plus a mirror 4xx fail where main would have retried GitHub and succeeded.

Once that's fixed, this needs to ship together with #24 in one action release, with sfw bumped to the release that has firewall#210.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Shared retries abort the healthy origin
    • A 404 now drops only that origin, so the remaining host still spends the full delay table and the thrown error is its last retryable failure.

Create PR

Or push these changes by commenting:

@cursor push 7eb0f516f4
Preview (7eb0f516f4)
diff --git a/.github/workflows/test-sfw-mirror.yml b/.github/workflows/test-sfw-mirror.yml
new file mode 100644
--- /dev/null
+++ b/.github/workflows/test-sfw-mirror.yml
@@ -1,0 +1,74 @@
+name: 'test: sfw mirror'
+run-name: 'test: sfw mirror'
+
+# Sfw binary download origins can fail, ensure the action still works
+# if only one of them is unavailable
+
+on:
+  push:
+    branches: [main]
+  pull_request:
+    branches: [main]
+  workflow_dispatch:
+
+permissions:
+  contents: read
+
+jobs:
+  fault-download-origin:
+    name: 'Block a download origin (${{ matrix.os }}, ${{ matrix.blocked }})'
+    runs-on: ${{ matrix.os }}
+    timeout-minutes: 20
+    strategy:
+      fail-fast: false
+      matrix:
+        os: [windows-2025, ubuntu-26.04]
+        # Each origin must carry the install alone. The github case only
+        # passes with the mirror fallback in the checked-out action.
+        blocked: [github, mirror]
+    steps:
+      - name: 'Bootstrap checkout'
+        shell: bash
+        env:
+          GITHUB_TOKEN: ${{ github.token }}
+          SERVER_URL: ${{ github.server_url }}
+          REPOSITORY: ${{ github.repository }}
+          TRIGGER_REF: ${{ github.sha }}
+        run: |
+          set -euo pipefail
+          git init -q
+          git config --local advice.detachedHead false
+          git remote add origin "${SERVER_URL}/${REPOSITORY}"
+          AUTH_B64="$(printf 'x-access-token:%s' "${GITHUB_TOKEN}" | base64 | tr -d '\n')"
+          export GIT_CONFIG_COUNT=1
+          export GIT_CONFIG_KEY_0="http.${SERVER_URL}/.extraheader"
+          export GIT_CONFIG_VALUE_0="AUTHORIZATION: basic ${AUTH_B64}"
+          git fetch --no-tags --prune --depth 1 origin "${TRIGGER_REF}"
+          git checkout -q --detach FETCH_HEAD
+      - name: 'Block the origin in the hosts file'
+        shell: bash
+        env:
+          BLOCKED: ${{ matrix.blocked }}
+          RUNNER_OS: ${{ runner.os }}
+        run: |
+          set -euo pipefail
+          if [ "$RUNNER_OS" = Windows ]; then HOSTS="$WINDIR/System32/drivers/etc/hosts"; else HOSTS=/etc/hosts; fi
+          if [ "$BLOCKED" = github ]; then
+            hosts="github.com objects.githubusercontent.com release-assets.githubusercontent.com"
+          else
+            hosts="install.socket.dev"
+          fi
+          # 127.0.0.1 refuses the connection at once. A black-hole address would
+          # make every attempt wait out a TCP connect timeout, which on Linux
+          # outlives the job.
+          for h in $hosts; do printf '127.0.0.1 %s\n' "$h" | sudo tee -a "$HOSTS" > /dev/null 2>&1 || printf '127.0.0.1 %s\n' "$h" >> "$HOSTS"; done
+          [ "$RUNNER_OS" = Windows ] && ipconfig //flushdns > /dev/null || true
+      - name: 'Install socket firewall via the remaining origin'
+        uses: ./
+        with:
+          mode: firewall
+          job-summary: errors
+          use-cache: 'false'
+      - name: 'Run the installed binary'
+        shell: bash
+        run: sfw --version

diff --git a/dist/main.js b/dist/main.js
--- a/dist/main.js
+++ b/dist/main.js
@@ -22135,6 +22135,12 @@
 */
 const DOWNLOAD_RETRY_DELAYS_SECONDS = [30, 60];
 /**
+* Socket-owned mirror of the sfw-free release binaries, relayed by
+* firewall-download-server in depscan. Free edition only: the enterprise
+* repository is private and not mirrored.
+*/
+const FIREWALL_FREE_MIRROR_BASE_URL = "https://install.socket.dev/firewall/dl";
+/**
 * Name the firewall binary is cached and executed under.
 */
 const FIREWALL_EXEC_NAME = "sfw";
@@ -22163,14 +22169,15 @@
 		versionToDownload,
 		process.arch
 	];
-	const url = `https://github.com/SocketDev/${repo}/releases/download/${versionToDownload}/${nameDownload}`;
+	const origins = firewallDownloadUrls(edition, repo, versionToDownload, nameDownload);
+	const urls = shuffledIndexes(origins.length).map((i) => origins[i]);
 	let pathCache;
 	if (inputs.useCache) pathCache = find(...cacheOptions);
 	if (!pathCache) {
-		debug(`downloading Socket Firewall binary from: ${url}`);
+		debug(`downloading Socket Firewall binary from: ${urls.join(", ")}`);
 		let pathDownload;
 		try {
-			pathDownload = await downloadToolWithRetry(url);
+			pathDownload = await downloadToolWithRetry(urls);
 		} catch (error) {
 			throw new Error(`Failed to download Socket Firewall binary: ${(0, import_message.errorMessage)(error)}`);
 		}
@@ -22196,26 +22203,61 @@
 	}
 }
 /**
-* `downloadTool` with attempts layered on top of its own. The last error is
-* rethrown untouched so the caller still reports the real cause.
+* `downloadTool` with attempts layered on top of its own, alternating between
+* equivalent origins. Retryable failures spend the delay table; an error that
+* a retry cannot change (a 404) still gets one immediate try per remaining
+* origin, because a missing asset on one host says nothing about the others.
+* The last error is rethrown untouched so the caller still reports the real
+* cause.
 *
-* @param {string} url Asset to download.
+* @param {string[]} urls Equivalent origins for the same asset, in the order
+*   to try them.
 *
 * @returns {Promise<string>} Path the asset was downloaded to.
 */
-async function downloadToolWithRetry(url) {
+async function downloadToolWithRetry(urls) {
 	let lastError;
-	for (let attempt = 0; attempt <= DOWNLOAD_RETRY_DELAYS_SECONDS.length; attempt += 1) try {
-		return await downloadTool(url);
-	} catch (error) {
-		lastError = error;
-		const seconds = DOWNLOAD_RETRY_DELAYS_SECONDS[attempt];
-		if (seconds === void 0 || !isRetryableDownloadError(error)) break;
-		warning(`Socket Firewall binary download failed (attempt ${attempt + 1} of ${DOWNLOAD_RETRY_DELAYS_SECONDS.length + 1}): ${(0, import_message.errorMessage)(error)}. Retrying in ${seconds}s.`);
-		await setTimeout$1(seconds * 1e3);
+	const remaining = urls.slice();
+	let attempt = 0;
+	while (remaining.length > 0) {
+		const url = remaining[attempt % remaining.length];
+		try {
+			return await downloadTool(url);
+		} catch (error) {
+			lastError = error;
+			if (!isRetryableDownloadError(error)) {
+				remaining.splice(remaining.indexOf(url), 1);
+				if (remaining.length === 0) break;
+				warning(`Socket Firewall binary download from ${url} failed: ${(0, import_message.errorMessage)(error)}. Trying ${remaining[attempt % remaining.length]}.`);
+				continue;
+			}
+			const seconds = DOWNLOAD_RETRY_DELAYS_SECONDS[attempt];
+			if (seconds === void 0) break;
+			warning(`Socket Firewall binary download from ${url} failed (attempt ${attempt + 1} of ${DOWNLOAD_RETRY_DELAYS_SECONDS.length + 1}): ${(0, import_message.errorMessage)(error)}. Retrying in ${seconds}s.`);
+			await setTimeout$1(seconds * 1e3);
+			attempt += 1;
+		}
 	}
 	throw lastError;
 }
+/**
+* Equivalent origins to download one release asset from. GitHub release
+* assets come first; the free edition is also mirrored on Socket-owned
+* infrastructure. The enterprise repository is private and not mirrored, so it
+* stays GitHub-only. Callers decide the order to try them in.
+*
+* @param {string} edition Firewall edition being installed.
+* @param {string} repo GitHub repository the release lives in.
+* @param {string} version Release tag to download.
+* @param {string} asset Release asset name.
+*
+* @returns {string[]} Download URLs, GitHub first.
+*/
+function firewallDownloadUrls(edition, repo, version, asset) {
+	const urls = [`https://github.com/SocketDev/${repo}/releases/download/${version}/${asset}`];
+	if (edition === "free") urls.push(`${FIREWALL_FREE_MIRROR_BASE_URL}/${version}/${asset}`);
+	return urls;
+}
 function firewallReleaseVersion(requestedVersion) {
 	let versionToDownload = FIREWALL_VERSION;
 	if (requestedVersion && requestedVersion !== "latest") {
@@ -22254,6 +22296,25 @@
 	return status >= 500 || status === 408 || status === 429;
 }
 /**
+* A random permutation of `0 .. length - 1` (Fisher-Yates), for callers that
+* need to try equivalent options in an unbiased order.
+*
+* @param {number} length How many indexes to permute.
+* @param {() => number} random Injectable for tests.
+*
+* @returns {number[]} Every index once, in random order.
+*/
+function shuffledIndexes(length, random = Math.random) {
+	const order = Array.from({ length }, (_, i) => i);
+	for (let i = order.length - 1; i > 0; i--) {
+		const j = Math.floor(random() * (i + 1));
+		const swap = order[i];
+		order[i] = order[j];
+		order[j] = swap;
+	}
+	return order;
+}
+/**
 * Compare a downloaded binary against the hash pinned for its release and
 * throw when they differ. Both hashes are printed so an operator can tell a
 * stale pin apart from a tampered download.

diff --git a/src/tools/firewall.js b/src/tools/firewall.js
--- a/src/tools/firewall.js
+++ b/src/tools/firewall.js
@@ -88,6 +88,14 @@
 export const DOWNLOAD_RETRY_DELAYS_SECONDS = [30, 60]
 
 /**
+ * Socket-owned mirror of the sfw-free release binaries, relayed by
+ * firewall-download-server in depscan. Free edition only: the enterprise
+ * repository is private and not mirrored.
+ */
+export const FIREWALL_FREE_MIRROR_BASE_URL =
+  'https://install.socket.dev/firewall/dl'
+
+/**
  * Name the firewall binary is cached and executed under.
  */
 export const FIREWALL_EXEC_NAME = 'sfw'
@@ -147,8 +155,16 @@
     process.arch,
   ]
 
-  // construct the download url
-  const url = `https://github.com/SocketDev/${repo}/releases/download/${versionToDownload}/${nameDownload}`
+  // construct the download urls, tried in a random order per job: half the
+  // fleet's downloads keep the mirror's edge cache warm, which is what lets it
+  // keep serving during a GitHub release-asset incident.
+  const origins = firewallDownloadUrls(
+    edition,
+    repo,
+    versionToDownload,
+    nameDownload,
+  )
+  const urls = shuffledIndexes(origins.length).map(i => origins[i])
 
   let pathCache
 
@@ -159,13 +175,13 @@
 
   // no cache, download new
   if (!pathCache) {
-    debug(`downloading Socket Firewall binary from: ${url}`)
+    debug(`downloading Socket Firewall binary from: ${urls.join(', ')}`)
 
     let pathDownload
 
     try {
       // download it
-      pathDownload = await downloadToolWithRetry(url)
+      pathDownload = await downloadToolWithRetry(urls)
     } catch (error) {
       throw new Error(
         `Failed to download Socket Firewall binary: ${errorMessage(error)}`,
@@ -228,43 +244,84 @@
 }
 
 /**
- * `downloadTool` with attempts layered on top of its own. The last error is
- * rethrown untouched so the caller still reports the real cause.
+ * `downloadTool` with attempts layered on top of its own, alternating between
+ * equivalent origins. Retryable failures spend the delay table; an error that
+ * a retry cannot change (a 404) still gets one immediate try per remaining
+ * origin, because a missing asset on one host says nothing about the others.
+ * The last error is rethrown untouched so the caller still reports the real
+ * cause.
  *
- * @param {string} url Asset to download.
+ * @param {string[]} urls Equivalent origins for the same asset, in the order
+ *   to try them.
  *
  * @returns {Promise<string>} Path the asset was downloaded to.
  */
-export async function downloadToolWithRetry(url) {
+export async function downloadToolWithRetry(urls) {
   let lastError
+  const remaining = urls.slice()
+  let attempt = 0
 
-  for (
-    let attempt = 0;
-    attempt <= DOWNLOAD_RETRY_DELAYS_SECONDS.length;
-    attempt += 1
-  ) {
+  while (remaining.length > 0) {
+    const url = remaining[attempt % remaining.length]
+
     try {
       return await downloadTool(url)
     } catch (error) {
       lastError = error
 
+      if (!isRetryableDownloadError(error)) {
+        remaining.splice(remaining.indexOf(url), 1)
+        if (remaining.length === 0) {
+          break
+        }
+        warning(
+          `Socket Firewall binary download from ${url} failed: ${errorMessage(error)}. Trying ${remaining[attempt % remaining.length]}.`,
+        )
+        continue
+      }
+
       const seconds = DOWNLOAD_RETRY_DELAYS_SECONDS[attempt]
 
-      if (seconds === undefined || !isRetryableDownloadError(error)) {
+      if (seconds === undefined) {
         break
       }
 
       warning(
-        `Socket Firewall binary download failed (attempt ${attempt + 1} of ${DOWNLOAD_RETRY_DELAYS_SECONDS.length + 1}): ${errorMessage(error)}. Retrying in ${seconds}s.`,
+        `Socket Firewall binary download from ${url} failed (attempt ${attempt + 1} of ${DOWNLOAD_RETRY_DELAYS_SECONDS.length + 1}): ${errorMessage(error)}. Retrying in ${seconds}s.`,
       )
-
       await setTimeout(seconds * 1000)
+      attempt += 1
     }
   }
 
   throw lastError
 }
 
+/**
+ * Equivalent origins to download one release asset from. GitHub release
+ * assets come first; the free edition is also mirrored on Socket-owned
+ * infrastructure. The enterprise repository is private and not mirrored, so it
+ * stays GitHub-only. Callers decide the order to try them in.
+ *
+ * @param {string} edition Firewall edition being installed.
+ * @param {string} repo GitHub repository the release lives in.
+ * @param {string} version Release tag to download.
+ * @param {string} asset Release asset name.
+ *
+ * @returns {string[]} Download URLs, GitHub first.
+ */
+export function firewallDownloadUrls(edition, repo, version, asset) {
+  const urls = [
+    `https://github.com/SocketDev/${repo}/releases/download/${version}/${asset}`,
+  ]
+
+  if (edition === 'free') {
+    urls.push(`${FIREWALL_FREE_MIRROR_BASE_URL}/${version}/${asset}`)
+  }
+
+  return urls
+}
+
 export function firewallReleaseVersion(requestedVersion) {
   let versionToDownload = FIREWALL_VERSION
 
@@ -316,6 +373,26 @@
 }
 
 /**
+ * A random permutation of `0 .. length - 1` (Fisher-Yates), for callers that
+ * need to try equivalent options in an unbiased order.
+ *
+ * @param {number} length How many indexes to permute.
+ * @param {() => number} random Injectable for tests.
+ *
+ * @returns {number[]} Every index once, in random order.
+ */
+export function shuffledIndexes(length, random = Math.random) {
+  const order = Array.from({ length }, (_, i) => i)
+  for (let i = order.length - 1; i > 0; i--) {
+    const j = Math.floor(random() * (i + 1))
+    const swap = order[i]
+    order[i] = order[j]
+    order[j] = swap
+  }
+  return order
+}
+
+/**
  * Compare a downloaded binary against the hash pinned for its release and
  * throw when they differ. Both hashes are printed so an operator can tell a
  * stale pin apart from a tampered download.

diff --git a/test/unit/tools/firewall.test.mts b/test/unit/tools/firewall.test.mts
--- a/test/unit/tools/firewall.test.mts
+++ b/test/unit/tools/firewall.test.mts
@@ -13,7 +13,9 @@
   downloadToolWithRetry,
   FIREWALL_DISTRIBUTIONS,
   FIREWALL_EXEC_NAME,
+  firewallDownloadUrls,
   isRetryableDownloadError,
+  shuffledIndexes,
 } from '../../../src/tools/firewall.js'
 
 const { mockDownloadTool, sleepDelays } = vi.hoisted(() => ({
@@ -147,14 +149,15 @@
 })
 
 describe('downloadToolWithRetry', () => {
+  const GITHUB = 'https://github.test/sfw'
+  const MIRROR = 'https://mirror.test/sfw'
+
   it('returns the path once an attempt succeeds', async () => {
     mockDownloadTool
       .mockRejectedValueOnce(httpError(504))
       .mockResolvedValueOnce('/tmp/sfw')
 
-    await expect(
-      downloadToolWithRetry('https://example.test/sfw'),
-    ).resolves.toBe('/tmp/sfw')
+    await expect(downloadToolWithRetry([GITHUB])).resolves.toBe('/tmp/sfw')
     expect(mockDownloadTool).toHaveBeenCalledTimes(2)
     expect(sleepDelays).toEqual([30_000])
   })
@@ -162,9 +165,9 @@
   it('rethrows the last error after exhausting its attempts', async () => {
     mockDownloadTool.mockRejectedValue(httpError(504))
 
-    await expect(
-      downloadToolWithRetry('https://example.test/sfw'),
-    ).rejects.toThrow('Unexpected HTTP response: 504')
+    await expect(downloadToolWithRetry([GITHUB])).rejects.toThrow(
+      'Unexpected HTTP response: 504',
+    )
     expect(mockDownloadTool).toHaveBeenCalledTimes(3)
     expect(sleepDelays).toEqual([30_000, 60_000])
   })
@@ -172,10 +175,147 @@
   it('does not spend attempts on a missing asset', async () => {
     mockDownloadTool.mockRejectedValue(httpError(404))
 
-    await expect(
-      downloadToolWithRetry('https://example.test/sfw'),
-    ).rejects.toThrow('Unexpected HTTP response: 404')
+    await expect(downloadToolWithRetry([GITHUB])).rejects.toThrow(
+      'Unexpected HTTP response: 404',
+    )
     expect(mockDownloadTool).toHaveBeenCalledTimes(1)
     expect(sleepDelays).toEqual([])
   })
+
+  it('alternates origins across the retry schedule', async () => {
+    mockDownloadTool.mockRejectedValue(httpError(504))
+
+    await expect(downloadToolWithRetry([MIRROR, GITHUB])).rejects.toThrow(
+      'Unexpected HTTP response: 504',
+    )
+    expect(mockDownloadTool.mock.calls).toEqual([[MIRROR], [GITHUB], [MIRROR]])
+    expect(sleepDelays).toEqual([30_000, 60_000])
+  })
+
+  it('tries the other origin immediately when a retry cannot help', async () => {
+    // A 404 from the mirror says nothing about GitHub: no backoff, one
+    // immediate try of the other origin.
+    mockDownloadTool
+      .mockRejectedValueOnce(httpError(404))
+      .mockResolvedValueOnce('/tmp/sfw')
+
+    await expect(downloadToolWithRetry([MIRROR, GITHUB])).resolves.toBe(
+      '/tmp/sfw',
+    )
+    expect(mockDownloadTool.mock.calls).toEqual([[MIRROR], [GITHUB]])
+    expect(sleepDelays).toEqual([])
+  })
+
+  it('gives up once every origin reported a missing asset', async () => {
+    mockDownloadTool.mockRejectedValue(httpError(404))
+
+    await expect(downloadToolWithRetry([MIRROR, GITHUB])).rejects.toThrow(
+      'Unexpected HTTP response: 404',
+    )
+    expect(mockDownloadTool).toHaveBeenCalledTimes(2)
+    expect(sleepDelays).toEqual([])
+  })
+
+  it('keeps the healthy origin on the delay table after a miss', async () => {
+    mockDownloadTool
+      .mockRejectedValueOnce(httpError(504))
+      .mockRejectedValueOnce(httpError(404))
+      .mockResolvedValueOnce('/tmp/sfw')
+
+    await expect(downloadToolWithRetry([GITHUB, MIRROR])).resolves.toBe(
+      '/tmp/sfw',
+    )
+    expect(mockDownloadTool.mock.calls).toEqual([[GITHUB], [MIRROR], [GITHUB]])
+    expect(sleepDelays).toEqual([30_000])
+  })
+
+  it('does not spend the delay table on a miss before a flaky origin', async () => {
+    mockDownloadTool
+      .mockRejectedValueOnce(httpError(404))
+      .mockRejectedValue(httpError(504))
+
+    await expect(downloadToolWithRetry([MIRROR, GITHUB])).rejects.toThrow(
+      'Unexpected HTTP response: 504',
+    )
+    expect(mockDownloadTool.mock.calls).toEqual([
+      [MIRROR],
+      [GITHUB],
+      [GITHUB],
+      [GITHUB],
+    ])
+    expect(sleepDelays).toEqual([30_000, 60_000])
+  })
+
+  it('rethrows the flaky origin after a later miss burns no retries', async () => {
+    mockDownloadTool
+      .mockRejectedValueOnce(httpError(504))
+      .mockRejectedValueOnce(httpError(404))
+      .mockRejectedValue(httpError(504))
+
+    await expect(downloadToolWithRetry([GITHUB, MIRROR])).rejects.toThrow(
+      'Unexpected HTTP response: 504',
+    )
+    expect(mockDownloadTool.mock.calls).toEqual([
+      [GITHUB],
+      [MIRROR],
+      [GITHUB],
+      [GITHUB],
+    ])
+    expect(sleepDelays).toEqual([30_000, 60_000])
+  })
 })
+
+describe('firewallDownloadUrls', () => {
+  it('gives the free edition both origins, GitHub first', () => {
+    expect(
+      firewallDownloadUrls(
+        'free',
+        'sfw-free',
+        'v1.15.2',
+        'sfw-free-linux-x86_64',
+      ),
+    ).toEqual([
+      'https://github.com/SocketDev/sfw-free/releases/download/v1.15.2/sfw-free-linux-x86_64',
+      'https://install.socket.dev/firewall/dl/v1.15.2/sfw-free-linux-x86_64',
+    ])
+  })
+
+  it('keeps the enterprise edition GitHub-only', () => {
+    expect(
+      firewallDownloadUrls(
+        'enterprise',
+        'firewall-release',
+        'v1.15.2',
+        'sfw-windows-x86_64.exe',
+      ),
+    ).toEqual([
+      'https://github.com/SocketDev/firewall-release/releases/download/v1.15.2/sfw-windows-x86_64.exe',
+    ])
+  })
+})
+
+describe('shuffledIndexes', () => {
+  it('permutes two indexes by the coin flip', () => {
+    expect(shuffledIndexes(2, () => 0.9)).toEqual([0, 1])
+    expect(shuffledIndexes(2, () => 0.1)).toEqual([1, 0])
+  })
+
+  it('permutes three indexes from the injected sequence', () => {
+    const draws = [0.9, 0.1]
+    expect(shuffledIndexes(3, () => draws.shift() ?? 0)).toEqual([1, 0, 2])
+  })
+
+  it('returns every index exactly once', () => {
+    for (let n = 0; n <= 5; n++) {
+      expect(shuffledIndexes(n).toSorted((a, b) => a - b)).toEqual(
+        Array.from({ length: n }, (_, i) => i),
+      )
+    }
+  })
+
+  it('does not consult the random source for a single index', () => {
+    const random = vi.fn(() => 0.1)
+    expect(shuffledIndexes(1, random)).toEqual([0])
+    expect(random).not.toHaveBeenCalled()
+  })
+})

You can send follow-ups to the cloud agent here.

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 6601666. Configure here.

Comment thread src/tools/firewall.js Outdated
)
} else {
break
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shared retries abort the healthy origin

High Severity

downloadToolWithRetry shares one three-attempt counter across origins and stops a 4xx once attempt is past the first pass. A mirror 404 then burns GitHub's remaining retries, so one GitHub 504 fails the free-edition install and the thrown error is the 404.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 6601666. Configure here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct for the code at 6601666, which still carried the shared counter by mistake. a563de5 is the intended change: each origin keeps its own 30s/60s budget, a non-retryable status drops only that origin, and the error rethrown is the last transient one. The [M 404, G 504, G ok] and [G 504, M 403] cases are unit tests.

The free-edition binary is now downloadable from two origins: GitHub
release assets, and the Socket-owned mirror at
install.socket.dev/firewall/dl/<version>/<binary> (served by
firewall-download-server in depscan). firewallDownloadUrls returns the
equivalent origins in a fixed order; downloadFirewall tries them in a
Fisher-Yates permutation from shuffledIndexes, so roughly half the
fleet's downloads keep the mirror's edge cache warm, which is what lets
it keep serving pinned binaries during a GitHub release-asset incident.
Adding a third origin needs no change to the selection.

downloadToolWithRetry takes the origin list and gives each origin the
whole 30s/60s retry schedule: a round tries every origin still in play,
and only a round with no success sits out the next delay. An origin
that fails with an error a retry cannot change (a 404, a 403) drops out
of later rounds, so a mirror that lacks the asset cannot use up the
retries GitHub needs to ride out a 504. The error rethrown is the last
transient one when there was one, since that is the outage worth
reporting; otherwise the last error seen.

The checksum table in this action's source validates every download
regardless of origin, so the second host cannot alter what gets
installed. Enterprise stays GitHub-only: firewall-release is private
and not mirrored.

test-sfw-mirror.yml runs on every pull request and forces the failure
this guards against: GitHub or the mirror pointed at 127.0.0.1 in the
hosts file, with the install expected to succeed from the other origin
on windows-2025 and ubuntu-26.04.

@Andre153 Andre Coetzee (Andre153) left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per-origin retries look right in a563de5. Ship together with #24, with sfw bumped to the release that has firewall#210.

@juliangruber
Julian Gruber (juliangruber) merged commit 59fe48b into main Oct 1, 2026
15 of 16 checks passed
Julian Gruber (juliangruber) added a commit that referenced this pull request Oct 1, 2026
sfw v1.15.4 restores the "not found in PATH" verdict for a command
PowerShell genuinely cannot resolve on Windows (SocketDev/firewall#210);
v1.15.3 reported that case as a resolver error. The action installs only
the version its checksum table covers, so the fix is not installable
through it until this bump.

Recompute all twelve checksums from the published v1.15.4 assets and
rebuild dist/.

Also move findCachedFirewall above firewallDownloadUrls. #18 and #24
each passed lint on their own, but merging both left the export out of
the alphabetical order the sort-source-methods rule wants, which fails
lint on main and blocks any commit touching this file.
Julian Gruber (juliangruber) added a commit that referenced this pull request Oct 1, 2026
sfw v1.15.4 restores the "not found in PATH" verdict for a command
PowerShell genuinely cannot resolve on Windows (SocketDev/firewall#210);
v1.15.3 reported that case as a resolver error. The action installs only
the version its checksum table covers, so the fix is not installable
through it until this bump.

Recompute all twelve checksums from the published v1.15.4 assets and
rebuild dist/.

Also move findCachedFirewall above firewallDownloadUrls. #18 and #24
each passed lint on their own, but merging both left the export out of
the alphabetical order the sort-source-methods rule wants, which fails
lint on main and blocks any commit touching this file.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants